From acdffc3d1dd8a4a427a2c81d046eb38080127da4 Mon Sep 17 00:00:00 2001 From: Paul Donald Date: Tue, 27 May 2025 17:11:25 +0200 Subject: [PATCH] luci-mod-system: Add repo key management Simplifies adding third-party repos. Functions on both 24.10 (opkg) and main (apk). Based loosely on sshkey management. Signed-off-by: Paul Donald --- .../resources/view/system/repokeys.js | 213 ++++++++++++++++++ .../share/luci/menu.d/luci-mod-system.json | 11 + .../usr/share/rpcd/acl.d/luci-mod-system.json | 16 ++ 3 files changed, 240 insertions(+) create mode 100644 modules/luci-mod-system/htdocs/luci-static/resources/view/system/repokeys.js diff --git a/modules/luci-mod-system/htdocs/luci-static/resources/view/system/repokeys.js b/modules/luci-mod-system/htdocs/luci-static/resources/view/system/repokeys.js new file mode 100644 index 0000000000..b502677bfd --- /dev/null +++ b/modules/luci-mod-system/htdocs/luci-static/resources/view/system/repokeys.js @@ -0,0 +1,213 @@ +'use strict'; +'require baseclass'; +'require view'; +'require fs'; +'require ui'; + +const APK_DIR = '/etc/apk/keys/'; +const OPKG_DIR = '/etc/opkg/keys/'; +const isReadonlyView = !L.hasViewPermission() || null; + +let KEYDIR = null; +let KEYEXT = null; + +/* This safeList is not bullet-proof, but should prevent users + accidentally deleting official repo keys */ +const safeList = [ + 'd310c6f2833e97f7', // 24.10 release usign key + 'openwrt-snapshots.pem', // main snapshots EC pub key +]; + +function isFileInSafeList(file){ + for (name of safeList) { + if (file === name) + return true; + } + return false; +} + +function normalizeKey(s) { + return s.replace(/\s+/g, ' ').trim(); +} + +function determineKeyEnv() { + return fs.stat(APK_DIR).then(() => { + KEYDIR = APK_DIR; + KEYEXT = '.pem'; // not strictly necessary - apk allows any extension + }).catch(() => { + KEYDIR = OPKG_DIR; + KEYEXT = null; // opkg requires key filenames without an extension + }); +} + +function listKeyFiles() { + return fs.list(dir).then(entries => + Promise.all(entries.map(entry => + fs.read(KEYDIR + entry.name).then(content => ({ + filename: entry.name, + key: content + })) + )) + ); +} + +function renderKeyItem(pubkey) { + const safeFile = isFileInSafeList(pubkey?.filename); + const lines = pubkey?.key?.trim()?.split('\n').map(line => + [ E('br'), E('code', line) ] + ).flat(); + return E('div', { + class: 'item', + click: (isReadonlyView || safeFile) ? null : removeKey, + 'data-file': pubkey?.filename, + 'data-key': normalizeKey(pubkey?.key) + }, [ + E('strong', [ pubkey?.filename || _('Unnamed key') ]), + ...lines + ]); +} + +function refreshKeyList(list, keys) { + while (!matchesElem(list.firstElementChild, '.add-item')) + list.removeChild(list.firstElementChild); + + keys.forEach(function(pubkey) { + list.insertBefore(renderKeyItem(pubkey), list.lastElementChild); + }); + + if (list.firstElementChild === list.lastElementChild) + list.insertBefore(E('p', _('No software repository public keys present yet.')), list.lastElementChild); +} + +function saveKeyFile(keyContent, file, fileContent) { + const ts = Date.now(); + // generate a file name in case key content was pasted + const filename = file ? file?.name?.split('.')?.[0] + (KEYEXT || '') : null; + const noname = 'key_' + ts + (KEYEXT || ''); + return fs.write(KEYDIR + (filename ?? noname), fileContent ?? keyContent, 384 /* 0600 */); +} + +function removeKey(ev) { + const file = ev.currentTarget.getAttribute('data-file'); + const list = findParent(ev.target, '.cbi-dynlist'); + + L.showModal(_('Delete key'), [ + E('div', _('Really delete the following software repository public key?')), + E('pre', [ file ]), + E('div', { class: 'right' }, [ + E('div', { class: 'btn', click: L.hideModal }, _('Cancel')), + ' ', + E('div', { + class: 'btn danger', + click: function() { + fs.remove(KEYDIR + file).then(() => { + return listKeyFiles().then(keys => refreshKeyList(list, keys)); + }); + ui.hideModal(); + } + }, _('Delete key')) + ]) + ]); +} + +function addKey(ev, file, fileContent) { + const list = findParent(ev.target, '.cbi-dynlist'); + const input = list.querySelector('textarea[type="text"]'); + const key = input.value; + + if (!key.length) + return; + + // Prevent duplicates + const exists = Array.from(list.querySelectorAll('.item')).some( + item => item.getAttribute('data-key') === normalizeKey(key) + ); + if (exists) { + ui.addTimeLimitedNotification(_('Add key'), [ + E('div', _('The given software repository public key is already present.')), + ], 7000, 'notice'); + return; + } + + input.value = ''; + saveKeyFile(key, file, fileContent) + .then(() => listKeyFiles()) + .then(keys => refreshKeyList(list, keys)) + .catch(e => ui.addNotification(null, E('p', e.message))); +} + +function dragKey(ev) { + ev.stopPropagation(); + ev.preventDefault(); + ev.dataTransfer.dropEffect = 'copy'; +} + +function dropKey(ev) { + ev.preventDefault(); + ev.stopPropagation(); + + const input = ev.currentTarget.querySelector('textarea[type="text"]'); + + for (const file of ev.dataTransfer.files) { + const reader = new FileReader(); + reader.onload = rev => { + input.value = rev.target.result; + addKey(ev, file, rev.target.result); + input.value = ''; + }; + reader.readAsText(file); + } +} + +function handleWindowDragDropIgnore(ev) { + ev.preventDefault(); +} + +return view.extend({ + load() { + return determineKeyEnv().then(listKeyFiles); + }, + + render(keys) { + const list = E('div', { + class: 'cbi-dynlist', + style: 'max-width: 800px', + dragover: isReadonlyView ? null : dragKey, + drop: isReadonlyView ? null : dropKey + }, [ + E('div', { class: 'add-item' }, [ + E('textarea', { + id: 'key-input', + 'aria-label': _('Paste or drag repository public key'), + class: 'cbi-input-text', + type: 'text', + placeholder: _('Paste or drag to upload a software repository public key…'), + keydown: function(ev) { if (ev.keyCode === 13) addKey(ev); }, + disabled: isReadonlyView + }), + E('button', { + class: 'cbi-button', + click: ui.createHandlerFn(this, addKey), + disabled: isReadonlyView + }, _('Add key')) + ]) + ]); + + refreshKeyList(list, keys); + window.addEventListener('dragover', handleWindowDragDropIgnore); + window.addEventListener('drop', handleWindowDragDropIgnore); + + return E('div', {}, [ + E('h2', _('Repository Public Keys')), + E('div', { class: 'cbi-section-descr' }, + _('Each software repository public key (from official or third party repositories) allows packages in lists signed by it to be installed by the package manager.')), + E('div', { class: 'cbi-section-descr' }, + _('Each key is stored as a file in %s.').format(KEYDIR)), + E('div', { class: 'cbi-section-node' }, list) + ]); + }, + + handleSaveApply: null, + handleSave: null, + handleReset: null +}); diff --git a/modules/luci-mod-system/root/usr/share/luci/menu.d/luci-mod-system.json b/modules/luci-mod-system/root/usr/share/luci/menu.d/luci-mod-system.json index ebae989d0e..0822d44845 100644 --- a/modules/luci-mod-system/root/usr/share/luci/menu.d/luci-mod-system.json +++ b/modules/luci-mod-system/root/usr/share/luci/menu.d/luci-mod-system.json @@ -73,6 +73,17 @@ } }, + "admin/system/admin/repokeys": { + "title": "Repo Public Keys", + "order": 5, + "action": { + "type": "view", + "path": "system/repokeys" + }, + "depends": { + "acl": [ "luci-mod-system-repokeys" ] + } + }, "admin/system/startup": { "title": "Startup", diff --git a/modules/luci-mod-system/root/usr/share/rpcd/acl.d/luci-mod-system.json b/modules/luci-mod-system/root/usr/share/rpcd/acl.d/luci-mod-system.json index b096d870a7..c61338893b 100644 --- a/modules/luci-mod-system/root/usr/share/rpcd/acl.d/luci-mod-system.json +++ b/modules/luci-mod-system/root/usr/share/rpcd/acl.d/luci-mod-system.json @@ -41,6 +41,22 @@ } }, + "luci-mod-system-repokeys": { + "description": "Grant access to Software Repository Public Key management", + "read": { + "file": { + "/etc/opkg/keys/*": [ "read" ], + "/etc/apk/keys/*": [ "read" ] + } + }, + "write": { + "file": { + "/etc/opkg/keys/*": [ "write" ], + "/etc/apk/keys/*": [ "write" ] + } + } + }, + "luci-mod-system-uhttpd": { "description": "Grant access to uHTTPd configuration", "read": { -- 2.30.2